本节代码对应 GitHub 分支: chapter9

仓库传送门 (opens new window)

# 骨架搭建

首先完成播放列表的轮廓,以及将它和播放器进行对接。

import React from 'react';
import {connect} from "react-redux";
import { PlayListWrapper, ScrollWrapper } from './style';
function PlayList (props) {
  return (
    <PlayListWrapper>
      <div className="list_wrapper">
        <ScrollWrapper></ScrollWrapper>
      </div>
    </PlayListWrapper>
  )
}
export default PlayList;

相应的 style.js 中:

import styled from'styled-components';
import style from '../../../assets/global-style';

export const PlayListWrapper = styled.div `
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 1000;
  background-color: ${style ["background-color-shadow"]};
  .list_wrapper {
    position: absolute;
    left: 0;
    bottom: 0;
    width: 100%;
    opacity: 1;
    border-radius: 10px 10px 0 0;
    background-color: ${style ["highlight-background-color"]};
    transform: translate3d (0, 0, 0);
    .list_close {
      text-align: center;
      line-height: 50px;
      background: ${style ["background-color"]};
      font-size: ${style ["font-size-l"]};
      color: ${style ["font-color-desc"]};
    }
  }
`;
export const ScrollWrapper = styled.div`
  height: 400px;
  overflow: hidden;
`;

现在你可以看到弹出的一个白色浮层了,这就是播放列表组件。现在我们将它和播放器做一下对接。

首先,需要在 Player/index.js 中,往 miniPlayer 和 normalPlayer 子组件中分别传入这个属性:

// 当然先要从 props 取出 togglePlayListDispatch,这部分大家自己加上即可
togglePlayList={togglePlayListDispatch}

然后在 miniPlayer/index.js 中,增加以下逻辑:

// 取出
const { togglePlayList } = props;
const handleTogglePlayList = (e) => {
  togglePlayList (true);
  e.stopPropagation ();
};

// 给列表图标绑定事件
<div className="control" onClick={handleTogglePlayList}>

同时,在 normalPlayer/index.js 中,增加:

const { togglePlayList } = props;
//...
<div
  className="icon i-right"
  onClick={() => togglePlayList (true)}
>

现在我们让 PlayList 组件对接上 redux 中的数据。

import { connect } from "react-redux";

// 组件代码省略

// 映射 Redux 全局的 state 到组件的 props 上
const mapStateToProps = (state) => ({
  showPlayList: state.getIn (['player', 'showPlayList']),
});
// 映射 dispatch 到 props 上
const mapDispatchToProps = (dispatch) => {
  return {
    togglePlayListDispatch (data) {
      dispatch (changeShowPlayList (data));
    }
  }
};

// 将 ui 组件包装成容器组件
export default connect (mapStateToProps, mapDispatchToProps)(React.memo (PlayList));

连接后我们专心来写组件内部的逻辑。

// 即将引入的模块
import { connect } from "react-redux";
import { PlayListWrapper, ScrollWrapper, ListHeader, ListContent } from './style';
import { CSSTransition } from 'react-transition-group';
import React, { useRef, useState, useCallback } from 'react';
import { prefixStyle, getName } from './../../../api/utils';
import { changeShowPlayList, changeCurrentIndex, changePlayMode, changePlayList } from "../store/actionCreators";
import { playMode } from "../../../api/config";
import Scroll from '../../../baseUI/scroll';


// 组件内代码
function PlayList (props) {
  const { showPlayList } = props;
  const { togglePlayListDispatch } = props;
  const playListRef = useRef ();
  const listWrapperRef = useRef ();
  const isShow = useState (false);

  return (
    <CSSTransition
      in={showPlayList}
      timeout={300}
      classNames="list-fade"
      onEnter={onEnterCB}
      onEntering={onEnteringCB}
      onExiting={onExitingCB}
      onExited={onExitedCB}
    >
      <PlayListWrapper
        ref={playListRef}
        style={isShow === true ? { display: "block" } : { display: "none" }}
        onClick={() => togglePlayListDispatch (false)}
      >
        <div className="list_wrapper" ref={listWrapperRef} >
          <ScrollWrapper></ScrollWrapper>
        </div>
      </PlayListWrapper>
    </CSSTransition>
  )
}

接下来编写动画钩子里面的回调函数:

import { prefixStyle } from './../../../api/utils';

const transform = prefixStyle ("transform");

const onEnterCB = useCallback (() => {
  // 让列表显示
  setIsShow (true);
  // 最开始是隐藏在下面
  listWrapperRef.current.style [transform] = `translate3d (0, 100%, 0)`;
}, [transform]);

const onEnteringCB = useCallback (() => {
  // 让列表展现
  listWrapperRef.current.style ["transition"] = "all 0.3s";
  listWrapperRef.current.style [transform] = `translate3d (0, 0, 0)`;
}, [transform]);

const onExitingCB = useCallback (() => {
  listWrapperRef.current.style ["transition"] = "all 0.3s";
  listWrapperRef.current.style [transform] = `translate3d (0px, 100%, 0px)`;
}, [transform]);

const onExitedCB = useCallback (() => {
  setIsShow (false);
  listWrapperRef.current.style [transform] = `translate3d (0px, 100%, 0px)`;
}, [transform]);

在 style.js 中增加动画部分:

export const PlayListWrapper = styled.div `
  /* 下面是动画部分的代码 */
  &.list-fade-enter {
    opacity: 0;
  }
  &.list-fade-enter-active {
    opacity: 1;
    transition: all 0.3s;
  }
  &.list-fade-exit {
    opacity: 1;
  }
  &.list-fade-exit-active {
    opacity: 0;
    transition: all 0.3s;
  }
`

现在大家点击列表图标便能弹出浮层了。

# 完成列表展示功能

现在我们来往浮层中增添列表的内容和功能。

首先,得从 redux 中拿到相应的数据。获取数据如下:

// 映射 Redux 全局的 state 到组件的 props 上
const mapStateToProps = (state) => ({
  currentIndex: state.getIn (['player', 'currentIndex']),
  currentSong: state.getIn (['player', 'currentSong']),
  playList: state.getIn (['player', 'playList']),// 播放列表
  sequencePlayList: state.getIn (['player', 'sequencePlayList']),// 顺序排列时的播放列表
  showPlayList: state.getIn (['player', 'showPlayList']),
  mode: state.getIn (['player', 'mode'])
});
// 映射 dispatch 到 props 上
const mapDispatchToProps = (dispatch) => {
  return {
    togglePlayListDispatch (data) {
      dispatch (changeShowPlayList (data));
    },
    // 修改当前歌曲在列表中的 index,也就是切歌
    changeCurrentIndexDispatch (data) {
      dispatch (changeCurrentIndex (data));
    },
    // 修改当前的播放模式
    changeModeDispatch (data) {
      dispatch (changePlayMode (data));
    },
    // 修改当前的歌曲列表
    changePlayListDispatch (data) {
      dispatch (changePlayList (data));
    },
  }
};

从 props 中导入:

const {
  currentIndex,
  currentSong:immutableCurrentSong,
  showPlayList,
  playList:immutablePlayList,
  mode,
  sequencePlayList:immutableSequencePlayList
} = props;
const {
  togglePlayListDispatch,
  changeCurrentIndexDispatch,
  changePlayListDispatch,
  changeModeDispatch,
} = props;

const currentSong = immutableCurrentSong.toJS ();
const playList = immutablePlayList.toJS ();
const sequencePlayList = immutableSequencePlayList.toJS ();

然后让列表组件对接这些数据,渲染出整个列表。JSX 结构如下:

//div.list_wrapper 标签中包裹下面的结构
<ListHeader>
  <h1 className="title">
    { getPlayMode () }
    <span className="iconfont clear" onClick={handleShowClear}>&#xe63d;</span>
  </h1>
</ListHeader>
<ScrollWrapper>
  <Scroll >
    <ListContent>
      {
        playList.map ((item, index) => {
          return (
            <li className="item" key={item.id}>
              {getCurrentIcon (item)}
              <span className="text">{item.name} - {getName (item.ar)}</span>
              <span className="like">
                <i className="iconfont">&#xe601;</i>
              </span>
              <span className="delete">
                <i className="iconfont">&#xe63d;</i>
              </span>
            </li>
          )
        })
      }
    </ListContent>
  </Scroll>
</ScrollWrapper>

其中有一些 UI 相关的逻辑封装,包括 getPlayMode、getPlayMode 和 changeMode,比较直观,没有参杂太多的业务逻辑,直接贴出代码:

const getCurrentIcon = (item) => {
  // 是不是当前正在播放的歌曲
  const current = currentSong.id === item.id;
  const className = current ? 'icon-play' : '';
  const content = current ? '&#xe6e3;': '';
  return (
    <i className={`current iconfont ${className}`} dangerouslySetInnerHTML={{__html:content}}></i>
  )
};
const getPlayMode = () => {
  let content, text;
  if (mode === playMode.sequence) {
    content = "&#xe625;";
    text = "顺序播放";
  } else if (mode === playMode.loop) {
    content = "&#xe653;";
    text = "单曲循环";
  } else {
    content = "&#xe61b;";
    text = "随机播放";
  }
  return (
    <div>
      <i className="iconfont" onClick={(e) => changeMode (e)}  dangerouslySetInnerHTML={{__html: content}}></i>
      <span className="text" onClick={(e) => changeMode (e)}>{text}</span>
    </div>
  )
};
const changeMode = (e) => {
  let newMode = (mode + 1) % 3;
  // 具体逻辑比较复杂 后面来实现
};

当然,还有对应的 style.js 中的样式组件,首先是 ListHead , 作为列表头部包裹播放模式和清空按钮的容器组件:

export const ListHeader = styled.div `
  position: relative;
  padding: 20px 30px 10px 20px;
  .title {
    display: flex;
    align-items: center;
    >div {
      flex:1;
      .text {
        flex: 1;
        font-size: ${style ["font-size-m"]};
        color: ${style ["font-color-desc"]};
      }
    }
    .iconfont {
      margin-right: 10px;
      font-size: ${style ["font-size-ll"]};
      color: ${style ["theme-color"]};
    }
    .clear {
      ${style.extendClick ()}
      font-size: ${style ["font-size-l"]};
    }
  }
`

ListContent 组件用来包裹整个歌曲的列表,是一个列表包裹组件, 样式代码如下:

export const ListContent = styled.div `
  .item {
    display: flex;
    align-items: center;
    height: 40px;
    padding: 0 30px 0 20px;
    overflow: hidden;
    .current {
      flex: 0 0 20px;
      width: 20px;
      font-size: ${style ["font-size-s"]};
      color: ${style ["theme-color"]};
    }
    .text {
      flex: 1;
      ${style.noWrap ()}
      font-size: ${style ["font-size-m"]};
      color: ${style ["font-color-desc-v2"]};
      .icon-favorite {
        color: ${style ["theme-color"]};
      }
    }
    .like {
      ${style.extendClick ()}
      margin-right: 15px;
      font-size: ${style ["font-size-m"]};
      color: ${style ["theme-color"]};
    }
    .delete {
      ${style.extendClick ()}
      font-size: ${style ["font-size-s"]};
      color: ${style ["theme-color"]};
    }
  }
`

现在列表的展示已经成功完成!接下来就是处理对应的业务逻辑了,梳理一下,分别是点击切歌、删除歌曲和切换播放模式这三大功能。

# 点击切歌实现

首先,我们需要绑定对应的事件:

const handleChangeCurrentIndex = (index) => {
  if (currentIndex === index) return;
  changeCurrentIndexDispatch (index);
}

// 绑定点击事件
<li className="item" key={item.id} onClick={() => handleChangeCurrentIndex (index)}>

你现在点击一下歌曲,好像可以切歌,但是你发现有一个问题:

当你点击之后列表突然被隐藏了。这个 bug 是怎么产生的呢?其实我们之前在 PlayWrapper 绑定了这样一个事件:

onClick={() => togglePlayListDispatch (false)}

其实这是为了在用户点击列表外部的时候,直接将列表隐藏掉,也符合常理。但是 PlayWrapper 的范围是整个屏幕,包含了列表内容,因此出现了这个 bug。

如何解决这个问题?

且看这样一行代码:

<div className="list_wrapper" ref={listWrapperRef} onClick={e => e.stopPropagation ()}>

在 list_wrapper 中绑定点击事件,阻止它冒泡就行了。因为这个 div 包裹的就是整个歌曲的列表。

OK!接下来,我们来实现删除歌曲的功能,这里面又包括删除一首歌曲和清空全部歌曲。

# 删除一首歌曲

import { deleteSong } from "../store/actionCreators";

const { deleteSongDispatch } = props;
const handleDeleteSong = (e, song) => {
  e.stopPropagation ();
  deleteSongDispatch (song);
};

<span className="delete" onClick={(e) => handleDeleteSong (e, item)}>
  <i className="iconfont">&#xe63d;</i>
</span>

重点在于 deleteSongDispatch 的逻辑,我们来一步步拆解它。

//mapDispatchToProps 中
deleteSongDispatch (data) {
  dispatch (deleteSong (data));
}

然后在 Player/store/constants.js 中增加:

export const DELETE_SONG = 'player/DELETE_SONG';

在 store/actionCreator.js 中导入 DELETE_SONG, 然后增加一个新的 action:

export const deleteSong = (data) => ({
  type: DELETE_SONG,
  data
});

现在转到 store/reducer.js 下编写删除的逻辑:

import { findIndex } from '../../../api/utils';// 注意引入工具方法
//...
const handleDeleteSong = (state, song) => {
  // 也可用 loadsh 库的 deepClone 方法。这里深拷贝是基于纯函数的考虑,不对参数 state 做修改
  const playList = JSON.parse (JSON.stringify (state.get ('playList').toJS ()));
  const sequenceList = JSON.parse (JSON.stringify (state.get ('sequencePlayList').toJS ()));
  let currentIndex = state.get ('currentIndex');
  // 找对应歌曲在播放列表中的索引
  const fpIndex = findIndex (song, playList);、
  // 在播放列表中将其删除
  playList.splice (fpIndex, 1);
  // 如果删除的歌曲排在当前播放歌曲前面,那么 currentIndex--,让当前的歌正常播放
  if (fpIndex < currentIndex) currentIndex--;

  // 在 sequenceList 中直接删除歌曲即可
  const fsIndex = findIndex (song, sequenceList);
  sequenceList.splice (fsIndex, 1);

  return state.merge ({
    'playList': fromJS (playList),
    'sequencePlayList': fromJS (sequenceList),
    'currentIndex': fromJS (currentIndex),
  });
}

export default (state = defaultState, action) => {
  switch (action.type) {
    //...
    case actionTypes.DELETE_SONG:
      return handleDeleteSong (state, action.data);
    default:
      return state;
  }
}

现在点击单个歌曲后面的删除按钮便能成功地将歌曲从列表删除啦!

# 清空歌曲功能

一般而言,删除全部是一个影响比较大的操作,如果弹出一个确定框,让用户点击确定再操作,无疑是更加合理的。

因此,我们首先来封装弹框组件,然后进行事件绑定。

在 baseUI 目录下新建 confirm 文件夹,然后新建 index.js 文件。

其代码从 代码地址 (opens new window) 中获取,也是一个非常基础的组件,里面的封装操作和之前的类似,就不再浪费篇幅了。

回到 PlayList 组件,我们引入 Confirm 组件:

import Confirm from './../../../baseUI/confirm/index';
const confirmRef = useRef ();
//JSX
return (
  <PlayListWrapper>
    //...
    <Confirm
      ref={confirmRef}
      text={"是否删除全部?"}
      cancelBtnText={"取消"}
      confirmBtnText={"确定"}
      handleConfirm={handleConfirmClear}
    />
  </PlayListWrapper>
)

现在来绑定一下清空事件:

const handleShowClear = () => {
  confirmRef.current.show ();
}

<span className="iconfont clear" onClick={handleShowClear}>&#xe63d;</span>

现在的工作是编写 Confirm 组件的回调函数 handleConfirmClear。

import { changeSequecePlayList, changeCurrentSong, changePlayingState } from '../store/actionCreators';
//...
const { clearDispatch } = props;
const handleConfirmClear = () => {
  clearDispatch ();
}

clearDispatch 在 mapDispatchToProps 中定义:

const mapDispatchToProps = (dispatch) => {
  return {
    //...
    clearDispatch () {
      // 1. 清空两个列表
      dispatch (changePlayList ([]));
      dispatch (changeSequecePlayList ([]));
      // 2. 初始 currentIndex
      dispatch (changeCurrentIndex (-1));
      // 3. 关闭 PlayList 的显示
      dispatch (changeShowPlayList (false));
      // 4. 将当前歌曲置空
      dispatch (changeCurrentSong ({}));
      // 5. 重置播放状态
      dispatch (changePlayingState (false));
    }
  }
};

# 修改播放模式

直接复用当时完成 normalPlayer 时修改播放模式的代码,当时我们实现过,估计你已经不陌生了。

// 从 utils.js 中再引入 shuffle 和 findIndex
import { prefixStyle, getName, shuffle, findIndex } from './../../../api/utils';

const changeMode = () => {
  let newMode = (mode + 1) % 3;
  if (newMode === 0) {
    // 顺序模式
    changePlayListDispatch (sequencePlayList);
    let index = findIndex (currentSong, sequencePlayList);
    changeCurrentIndexDispatch (index);
  } else if (newMode === 1) {
    // 单曲循环
    changePlayListDispatch (sequencePlayList);
  } else if (newMode === 2) {
    // 随机播放
    let newList = shuffle (sequencePlayList);
    let index = findIndex (currentSong, newList);
    changePlayListDispatch (newList);
    changeCurrentIndexDispatch (index);
  }
  changeModeDispatch (newMode);
};

# 下滑关闭及反弹效果

作为一个精美的 App,在完成基本功能的同时,我们也有其他交互细节的考量。比如在安卓中下滑小段距离时会有反弹,下滑超过了一定阈值就会关闭浮层。现在就带大家来完成这个移动端常用的功能。

实现这个交互的关键在于利用好 touchStart, touchMove, touchEnd 这三个事件的回调。

首先来绑定事件:

const handleTouchStart = (e) => {};
const handleTouchMove = (e) => {};
const handleTouchEnd = (e) => {};
//...
<div
  className="list_wrapper"
  ref={listWrapperRef}
  onClick={e => e.stopPropagation ()}
  onTouchStart={handleTouchStart}
  onTouchMove={handleTouchMove}
  onTouchEnd={handleTouchEnd}
>

其次,对于 Scroll 组件:

// 是否允许滑动事件生效
const [canTouch,setCanTouch] = useState (true);

const listContentRef = useRef ();
const handleScroll = (pos) => {
  // 只有当内容偏移量为 0 的时候才能下滑关闭 PlayList。否则一边内容在移动,一边列表在移动,出现 bug
  let state = pos.y === 0;
  setCanTouch (state);
}

<Scroll
  ref={listContentRef}
  onScroll={pos => handleScroll (pos)}
  bounceTop={false}
>

接下来我们来具体地编写那三个 touch 事件的回调函数。

首先初始化三个变量:

//touchStart 后记录 y 值
const [startY, setStartY] = useState (0);
//touchStart 事件是否已经被触发
const [initialed, setInitialed] = useState (0);
// 用户下滑的距离
const [distance, setDistance] = useState (0);

对于 touchStart 事件:

const handleTouchStart = (e) => {
  if (!canTouch || initialed) return;
  listWrapperRef.current.style ["transition"] = "";
  setStartY (e.nativeEvent.touches [0].pageY);// 记录 y 值
  setInitialed (true);
};

对于 touchMove 事件:

const handleTouchMove = (e) => {
  if (!canTouch || !initialed) return;
  let distance = e.nativeEvent.touches [0].pageY - startY;
  if (distance < 0) return;
  setDistance (distance);// 记录下滑距离
  listWrapperRef.current.style.transform = `translate3d (0, ${distance} px, 0)`;
};

对于 touchEnd:

const handleTouchEnd = (e) => {
  setInitialed (false);
  // 这里设置阈值为 150px
  if (distance >= 150) {
    // 大于 150px 则关闭 PlayList
    togglePlayListDispatch (false);
  } else {
    // 否则反弹回去
    listWrapperRef.current.style ["transition"] = "all 0.3s";
    listWrapperRef.current.style [transform] = `translate3d (0px, 0px, 0px)`;
  }
};

恭喜你,现在终于开发完成了这个看似简单却实际上并不简单的 PlayList 组件。如今播放器的功能已经比较完整了,但是仍然有一个非常重要的功能需要完成 —— 歌词功能,下一节就让我们开始歌词开发的第一步 —— 歌词解析插件的封装。

阅读全文